
const addSeparators = function (nStr, thousandsSep, decimalSep) {
  const x = String(nStr).split('.')
  let x1 = x[0]
  const x2 = x.length > 1 ? decimalSep + x[1] : ''
  const rgx = /(\d+)(\d{3})/
  while (rgx.test(x1)) {
    x1 = x1.replace(rgx, `$1${thousandsSep}$2`)
  }
  return x1 + x2
}

const numberFormat = function (optsIn) {
  const defaults = {
    digitsAfterDecimal: 2,
    scaler: 1,
    thousandsSep: ',',
    decimalSep: '.',
    prefix: '',
    suffix: ''
  }
  const opts = Object.assign({}, defaults, optsIn)
  return function (x) {
    if (isNaN(x) || !isFinite(x)) {
      return ''
    }
    const result = addSeparators(
      (opts.scaler * x).toFixed(opts.digitsAfterDecimal),
      opts.thousandsSep,
      opts.decimalSep
    )
    return `${opts.prefix}${result}${opts.suffix}`
  }
}

const rx = /(\d+)|(\D+)/g
const rd = /\d/
const rz = /^0/
const naturalSort = (as, bs) => {
  // nulls first
  if (bs !== null && as === null) {
    return -1
  }
  if (as !== null && bs === null) {
    return 1
  }

  // then raw NaNs
  if (typeof as === 'number' && isNaN(as)) {
    return -1
  }
  if (typeof bs === 'number' && isNaN(bs)) {
    return 1
  }

  // numbers and numbery strings group together
  const nas = Number(as)
  const nbs = Number(bs)
  if (nas < nbs) {
    return -1
  }
  if (nas > nbs) {
    return 1
  }

  // within that, true numbers before numbery strings
  if (typeof as === 'number' && typeof bs !== 'number') {
    return -1
  }
  if (typeof bs === 'number' && typeof as !== 'number') {
    return 1
  }
  if (typeof as === 'number' && typeof bs === 'number') {
    return 0
  }

  // 'Infinity' is a textual number, so less than 'A'
  if (isNaN(nbs) && !isNaN(nas)) {
    return -1
  }
  if (isNaN(nas) && !isNaN(nbs)) {
    return 1
  }

  // finally, "smart" string sorting per http://stackoverflow.com/a/4373421/112871
  let a = String(as)
  let b = String(bs)
  if (a === b) {
    return 0
  }
  if (!rd.test(a) || !rd.test(b)) {
    return a > b ? 1 : -1
  }

  // special treatment for strings containing digits
  a = a.match(rx)
  b = b.match(rx)
  while (a.length && b.length) {
    const a1 = a.shift()
    const b1 = b.shift()
    if (a1 !== b1) {
      if (rd.test(a1) && rd.test(b1)) {
        return a1.replace(rz, '.0') - b1.replace(rz, '.0')
      }
      return a1 > b1 ? 1 : -1
    }
  }
  return a.length - b.length
}
const sortAs = function (order) {
  const mapping = {}

  // sort lowercased keys similarly
  const lMapping = {}
  for (const i in order) {
    const x = order[i]
    mapping[x] = i
    if (typeof x === 'string') {
      lMapping[x.toLowerCase()] = i
    }
  }
  return function (a, b) {
    if (a in mapping && b in mapping) {
      return mapping[a] - mapping[b]
    } else if (a in mapping) {
      return -1
    } else if (b in mapping) {
      return 1
    } else if (a in lMapping && b in lMapping) {
      return lMapping[a] - lMapping[b]
    } else if (a in lMapping) {
      return -1
    } else if (b in lMapping) {
      return 1
    }
    return naturalSort(a, b)
  }
}

const getSort = function (sorters, attr) {
  if (sorters) {
    if (typeof sorters === 'function') {
      const sort = sorters(attr)
      if (typeof sort === 'function') {
        return sort
      }
    } else if (attr in sorters) {
      return sorters[attr]
    }
  }
  return naturalSort
}

// aggregator templates default to US number formatting but this is overrideable
const usFmt = numberFormat()
const usFmtInt = numberFormat({ digitsAfterDecimal: 0 })
const usFmtPct = numberFormat({
  digitsAfterDecimal: 1,
  scaler: 100,
  suffix: '%'
})

const aggregatorTemplates = {
  count (formatter = usFmtInt) {
    return () =>
      function () {
        return {
          count: 0,
          push () {
            this.count++
          },
          value () {
            return this.count
          },
          format: formatter
        }
      }
  },

  uniques (fn, formatter = usFmtInt) {
    return function ([attr]) {
      return function () {
        return {
          uniq: [],
          push (record) {
            if (!Array.from(this.uniq).includes(record[attr])) {
              this.uniq.push(record[attr])
            }
          },
          value () {
            return fn(this.uniq)
          },
          format: formatter,
          numInputs: typeof attr !== 'undefined' ? 0 : 1
        }
      }
    }
  },

  sum (formatter = usFmt) {
    return function ([attr]) {
      return function () {
        return {
          sum: 0,
          push (record) {
            if (!isNaN(parseFloat(record[attr]))) {
              this.sum += parseFloat(record[attr])
            }
          },
          value () {
            return this.sum
          },
          format: formatter,
          numInputs: typeof attr !== 'undefined' ? 0 : 1
        }
      }
    }
  },

  extremes (mode, formatter = usFmt) {
    return function ([attr]) {
      return function (data) {
        return {
          val: null,
          sorter: getSort(
            typeof data !== 'undefined' ? data.sorters : null,
            attr
          ),
          push (record) {
            let x = record[attr]
            if (['min', 'max'].includes(mode)) {
              x = parseFloat(x)
              if (!isNaN(x)) {
                this.val = Math[mode](x, this.val !== null ? this.val : x)
              }
            }
            if (
              mode === 'first' && this.sorter(x, this.val !== null ? this.val : x) <= 0
            ) {
              this.val = x
            }
            if (
              mode === 'last' && this.sorter(x, this.val !== null ? this.val : x) >= 0
            ) {
              this.val = x
            }
          },
          value () {
            return this.val
          },
          format (x) {
            if (isNaN(x)) {
              return x
            }
            return formatter(x)
          },
          numInputs: typeof attr !== 'undefined' ? 0 : 1
        }
      }
    }
  },

  quantile (q, formatter = usFmt) {
    return function ([attr]) {
      return function () {
        return {
          vals: [],
          push (record) {
            const x = parseFloat(record[attr])
            if (!isNaN(x)) {
              this.vals.push(x)
            }
          },
          value () {
            if (this.vals.length === 0) {
              return null
            }
            this.vals.sort((a, b) => a - b)
            const i = (this.vals.length - 1) * q
            return (this.vals[Math.floor(i)] + this.vals[Math.ceil(i)]) / 2.0
          },
          format: formatter,
          numInputs: typeof attr !== 'undefined' ? 0 : 1
        }
      }
    }
  },

  runningStat (mode = 'mean', ddof = 1, formatter = usFmt) {
    return function ([attr]) {
      return function () {
        return {
          n: 0.0,
          m: 0.0,
          s: 0.0,
          push (record) {
            const x = parseFloat(record[attr])
            if (isNaN(x)) {
              return
            }
            this.n += 1.0
            if (this.n === 1.0) {
              this.m = x
            }
            const mNew = this.m + (x - this.m) / this.n
            this.s = this.s + (x - this.m) * (x - mNew)
            this.m = mNew
          },
          value () {
            if (mode === 'mean') {
              if (this.n === 0) {
                return 0 / 0
              }
              return this.m
            }
            if (this.n <= ddof) {
              return 0
            }
            switch (mode) {
              case 'var':
                return this.s / (this.n - ddof)
              case 'stdev':
                return Math.sqrt(this.s / (this.n - ddof))
              default:
                throw new Error('unknown mode for runningStat')
            }
          },
          format: formatter,
          numInputs: typeof attr !== 'undefined' ? 0 : 1
        }
      }
    }
  },

  sumOverSum (formatter = usFmt) {
    return function ([num, denom]) {
      return function () {
        return {
          sumNum: 0,
          sumDenom: 0,
          push (record) {
            if (!isNaN(parseFloat(record[num]))) {
              this.sumNum += parseFloat(record[num])
            }
            if (!isNaN(parseFloat(record[denom]))) {
              this.sumDenom += parseFloat(record[denom])
            }
          },
          value () {
            return this.sumNum / this.sumDenom
          },
          format: formatter,
          numInputs: typeof num !== 'undefined' && typeof denom !== 'undefined' ? 0 : 2
        }
      }
    }
  },

  fractionOf (wrapped, type = 'total', formatter = usFmtPct) {
    return (...x) =>
      function (data, rowKey, colKey) {
        return {
          selector: { total: [[], []], row: [rowKey, []], col: [[], colKey] }[type],
          inner: wrapped(...Array.from(x || []))(data, rowKey, colKey),
          push (record) {
            this.inner.push(record)
          },
          format: formatter,
          value () {
            return (
              this.inner.value() / data.getAggregator(...Array.from(this.selector || [])).inner.value()
            )
          },
          numInputs: wrapped(...Array.from(x || []))().numInputs
        }
      }
  }
}

aggregatorTemplates.countUnique = f =>
  aggregatorTemplates.uniques(x => x.length, f)
aggregatorTemplates.listUnique = s =>
  aggregatorTemplates.uniques(x => x.join(s), x => x)
aggregatorTemplates.max = f => aggregatorTemplates.extremes('max', f)
aggregatorTemplates.min = f => aggregatorTemplates.extremes('min', f)
aggregatorTemplates.first = f => aggregatorTemplates.extremes('first', f)
aggregatorTemplates.last = f => aggregatorTemplates.extremes('last', f)
aggregatorTemplates.median = f => aggregatorTemplates.quantile(0.5, f)
aggregatorTemplates.average = f =>
  aggregatorTemplates.runningStat('mean', 1, f)
aggregatorTemplates.var = (ddof, f) =>
  aggregatorTemplates.runningStat('var', ddof, f)
aggregatorTemplates.stdev = (ddof, f) =>
  aggregatorTemplates.runningStat('stdev', ddof, f)

// default aggregators & renderers use US naming and number formatting
const aggregators = (tpl => ({
  Count: tpl.count(usFmtInt),
  'Count Unique Values': tpl.countUnique(usFmtInt),
  'List Unique Values': tpl.listUnique(', '),
  Sum: tpl.sum(usFmt),
  'Integer Sum': tpl.sum(usFmtInt),
  Average: tpl.average(usFmt),
  Median: tpl.median(usFmt),
  'Sample Variance': tpl.var(1, usFmt),
  'Sample Standard Deviation': tpl.stdev(1, usFmt),
  Minimum: tpl.min(usFmt),
  Maximum: tpl.max(usFmt),
  First: tpl.first(usFmt),
  Last: tpl.last(usFmt),
  'Sum over Sum': tpl.sumOverSum(usFmt),
  'Sum as Fraction of Total': tpl.fractionOf(tpl.sum(), 'total', usFmtPct),
  'Sum as Fraction of Rows': tpl.fractionOf(tpl.sum(), 'row', usFmtPct),
  'Sum as Fraction of Columns': tpl.fractionOf(tpl.sum(), 'col', usFmtPct),
  'Count as Fraction of Total': tpl.fractionOf(tpl.count(), 'total', usFmtPct),
  'Count as Fraction of Rows': tpl.fractionOf(tpl.count(), 'row', usFmtPct),
  'Count as Fraction of Columns': tpl.fractionOf(tpl.count(), 'col', usFmtPct)
}))(aggregatorTemplates)

const locales = {
  en: {
    aggregators,
    localeStrings: {
      renderError: 'An error occurred rendering the PivotTable results.',
      computeError: 'An error occurred computing the PivotTable results.',
      uiRenderError: 'An error occurred rendering the PivotTable UI.',
      selectAll: 'Select All',
      selectNone: 'Select None',
      tooMany: '(too many to list)',
      filterResults: 'Filter values',
      totals: 'Totals',
      vs: 'vs',
      by: 'by'
    }
  }
}

// dateFormat deriver l10n requires month and day names to be passed in directly
const mthNamesEn = [
  'Jan',
  'Feb',
  'Mar',
  'Apr',
  'May',
  'Jun',
  'Jul',
  'Aug',
  'Sep',
  'Oct',
  'Nov',
  'Dec'
]
const dayNamesEn = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const zeroPad = number => `0${number}`.substr(-2, 2) // eslint-disable-line no-magic-numbers

const derivers = {
  bin (col, binWidth) {
    return record => record[col] - record[col] % binWidth
  },
  dateFormat (
    col,
    formatString,
    utcOutput = false,
    mthNames = mthNamesEn,
    dayNames = dayNamesEn
  ) {
    const utc = utcOutput ? 'UTC' : ''
    return function (record) {
      const date = new Date(Date.parse(record[col]))
      if (isNaN(date)) {
        return ''
      }
      return formatString.replace(/%(.)/g, function (m, p) {
        switch (p) {
          case 'y':
            return date[`get${utc}FullYear`]()
          case 'm':
            return zeroPad(date[`get${utc}Month`]() + 1)
          case 'n':
            return mthNames[date[`get${utc}Month`]()]
          case 'd':
            return zeroPad(date[`get${utc}Date`]())
          case 'w':
            return dayNames[date[`get${utc}Day`]()]
          case 'x':
            return date[`get${utc}Day`]()
          case 'H':
            return zeroPad(date[`get${utc}Hours`]())
          case 'M':
            return zeroPad(date[`get${utc}Minutes`]())
          case 'S':
            return zeroPad(date[`get${utc}Seconds`]())
          default:
            return `%${p}`
        }
      })
    }
  }
}

/*
Data Model class
*/

class PivotData {
  constructor (inputProps = {}) {
    this.props = Object.assign({}, PivotData.defaultProps, inputProps)
    this.aggregator = this.props.aggregators[this.props.aggregatorName](
      this.props.vals
    )
    this.tree = {}
    this.rowKeys = []
    this.colKeys = []
    this.rowTotals = {}
    this.colTotals = {}
    this.allTotal = this.aggregator(this, [], [])
    this.sorted = false
    this.filteredData = []
    // iterate through input, accumulating data for cells
    PivotData.forEachRecord(
      this.props.data,
      this.props.derivedAttributes,
      record => {
        if (this.filter(record)) {
          this.filteredData.push(record)
          this.processRecord(record)
        }
      }
    )
  }
  filter (record) {
    for (const k in this.props.valueFilter) {
      if (record[k] in this.props.valueFilter[k]) {
        return false
      }
    }
    return true
  }

  forEachMatchingRecord (criteria, callback) {
    return PivotData.forEachRecord(
      this.props.data,
      this.props.derivedAttributes,
      record => {
        if (!this.filter(record)) {
          return
        }
        for (const k in criteria) {
          const v = criteria[k]
          if (v !== (k in record ? record[k] : 'null')) {
            return
          }
        }
        callback(record)
      }
    )
  }

  arrSort (attrs) {
    let a
    const sortersArr = (() => {
      const result = []
      for (a of Array.from(attrs)) {
        result.push(getSort(this.props.sorters, a))
      }
      return result
    })()
    return function (a, b) {
      for (const i of Object.keys(sortersArr || {})) {
        const sorter = sortersArr[i]
        const comparison = sorter(a[i], b[i])
        if (comparison !== 0) {
          return comparison
        }
      }
      return 0
    }
  }

  sortKeys () {
    if (!this.sorted) {
      this.sorted = true
      const v = (r, c) => this.getAggregator(r, c).value()
      switch (this.props.rowOrder) {
        case 'value_a_to_z':
          this.rowKeys.sort((a, b) => naturalSort(v(a, []), v(b, [])))
          break
        case 'value_z_to_a':
          this.rowKeys.sort((a, b) => -naturalSort(v(a, []), v(b, [])))
          break
        default:
          this.rowKeys.sort(this.arrSort(this.props.rows))
      }
      switch (this.props.colOrder) {
        case 'value_a_to_z':
          this.colKeys.sort((a, b) => naturalSort(v([], a), v([], b)))
          break
        case 'value_z_to_a':
          this.colKeys.sort((a, b) => -naturalSort(v([], a), v([], b)))
          break
        default:
          this.colKeys.sort(this.arrSort(this.props.cols))
      }
    }
  }

  getFilteredData () {
    return this.filteredData
  }

  getColKeys () {
    this.sortKeys()
    return this.colKeys
  }

  getRowKeys () {
    this.sortKeys()
    return this.rowKeys
  }

  processRecord (record) {
    // this code is called in a tight loop
    const colKey = []
    const rowKey = []
    for (const x of Array.from(this.props.cols)) {
      colKey.push(x in record ? record[x] : 'null')
    }
    for (const x of Array.from(this.props.rows)) {
      rowKey.push(x in record ? record[x] : 'null')
    }
    const flatRowKey = rowKey.join(String.fromCharCode(0))
    const flatColKey = colKey.join(String.fromCharCode(0))

    this.allTotal.push(record)

    if (rowKey.length !== 0) {
      if (!this.rowTotals[flatRowKey]) {
        this.rowKeys.push(rowKey)
        this.rowTotals[flatRowKey] = this.aggregator(this, rowKey, [])
      }
      this.rowTotals[flatRowKey].push(record)
    }

    if (colKey.length !== 0) {
      if (!this.colTotals[flatColKey]) {
        this.colKeys.push(colKey)
        this.colTotals[flatColKey] = this.aggregator(this, [], colKey)
      }
      this.colTotals[flatColKey].push(record)
    }

    if (colKey.length !== 0 && rowKey.length !== 0) {
      if (!this.tree[flatRowKey]) {
        this.tree[flatRowKey] = {}
      }
      if (!this.tree[flatRowKey][flatColKey]) {
        this.tree[flatRowKey][flatColKey] = this.aggregator(
          this,
          rowKey,
          colKey
        )
      }
      this.tree[flatRowKey][flatColKey].push(record)
    }
  }

  getAggregator (rowKey, colKey) {
    let agg
    const flatRowKey = rowKey.join(String.fromCharCode(0))
    const flatColKey = colKey.join(String.fromCharCode(0))
    if (rowKey.length === 0 && colKey.length === 0) {
      agg = this.allTotal
    } else if (rowKey.length === 0) {
      agg = this.colTotals[flatColKey]
    } else if (colKey.length === 0) {
      agg = this.rowTotals[flatRowKey]
    } else {
      agg = this.tree[flatRowKey][flatColKey]
    }
    return (
      agg || {
        value () {
          return null
        },
        format () {
          return ''
        }
      }
    )
  }
}

// can handle arrays or jQuery selections of tables
PivotData.forEachRecord = function (input, derivedAttributes, f) {
  let addRecord, record
  if (Object.getOwnPropertyNames(derivedAttributes).length === 0) {
    addRecord = f
  } else {
    addRecord = function (record) {
      for (const k in derivedAttributes) {
        const derived = derivedAttributes[k](record)
        if (derived !== null) {
          record[k] = derived
        }
      }
      return f(record)
    }
  }

  // if it's a function, have it call us back
  if (typeof input === 'function') {
    return input(addRecord)
  } else if (Array.isArray(input)) {
    if (Array.isArray(input[0])) {
      // array of arrays
      return (() => {
        const result = []
        for (const i of Object.keys(input || {})) {
          const compactRecord = input[i]
          if (i > 0) {
            record = {}
            for (const j of Object.keys(input[0] || {})) {
              const k = input[0][j]
              record[k] = compactRecord[j]
            }
            result.push(addRecord(record))
          }
        }
        return result
      })()
    }

    // array of objects
    return (() => {
      const result1 = []
      for (record of Array.from(input)) {
        result1.push(addRecord(record))
      }
      return result1
    })()
  }
  throw new Error('unknown input format')
}

PivotData.defaultProps = {
  aggregators: aggregators,
  cols: [],
  rows: [],
  vals: [],
  aggregatorName: 'Count',
  sorters: {},
  valueFilter: {},
  rowOrder: 'key_a_to_z',
  colOrder: 'key_a_to_z',
  derivedAttributes: {}
}

export {
  aggregatorTemplates,
  aggregators,
  derivers,
  locales,
  naturalSort,
  numberFormat,
  getSort,
  sortAs,
  PivotData
}
